<?php

// vim: expandtab shiftwidth=2 softtabstop=2 tabstop=2

/**
 * Provide class for parsing entry body
 *
 * PHP version 5
 *
 * @category  Classes
 * @package   mPack
 * @author    Marcin Sztolcman <marcin /at/ urzenia /dot/ net>
 * @copyright 2006 Marcin Sztolcman
 * @license   GPL v.2
 * @version   SVN: $Id$
 * @link      http://m-pack.googlecode.com
 */

/**
 * Class for parsing entries.
 *
 * Parsing entry body, and delete unallowed html tags, or unallowed attributes
 * (like onclick/onmouseover etc, which provides tools for any kind of XSS
 * (Cross Site Scripting).
 * Other benefit from this class is smart replacing new line chars on html
 * < br /> tag.
 *
 * @category  Classes
 * @package   mPack
 * @author    Marcin Sztolcman <marcin /at/ urzenia /dot/ net>
 * @copyright 2006 Marcin Sztolcman
 * @license   GPL v.2
 * @version   SVN: $Id$
 * @link      http://m-pack.googlecode.com
 */
class mParser {
  /**
   * Constant
   *
   * Holds what virtual tag is used if source is not well formed xml.
   */
  const VIRTTAG                     = 'parser:virtual';

  /**
   * Allow to eval PHP code?
   *
   * @var boolean
   * @access public
   */
  public $parsePHP                  = false;

  /**
   * Replace new line chars (\r\n or \n or \r) with < br />?
   *
   * @var boolean
   * @access public
   */
  public $nl2br                     = true;

  /**
   * Define when replace new line chars on html < br/>
   *
   * Used by Parse::_cdata()
   *
   * @var  integer
   * @access private
   */
  private $_nl2br                   = 0;

  /**
   * Hold tags which content is not nl2br'ed
   *
   * Doesn't affect child of these tags.
   *
   * @var array
   * @access private
   */
  static private $_safe             = array('style', 'ul', 'ol', 'dl', 'html');

  /**
   * Hold tags which content (and theyre childs too) is not nl2br'ed.
   *
   * @var  array
   * @access private
   */
  static private $_safeTree         = array('head', 'script', 'pre', 'style');

  /**
   * If true, strip <b>all</b> new line chars and with empty lines.
   *
   * If mParser::$newline is set to non blank, it is not stripped. If You want
   * to strip <b>all</b> new line chars, with mParser::$newline, You must set
   * mParser::$newline to blank.
   *
   * @var boolean
   * @access public
   */
  public $strip                     = false;

  /**
   * This chars will be inserted past < br />.
   *
   * If You want not to strip new line chars from data, you may set this
   * para as "\n" (for example). If You want to strip - set this to blank.
   * <code>mParser::$newline = "\n";
   *mParser::$newline = "\r";
   *mParser::$newline = "\r\n";</code>
   *
   * @var string
   * @access public
   */
  public $newline                   = "\n";

  /**
   * Array with tags which aren't stripped from input data
   *
   * @var  array
   * @access private
   */
  static private $_tagsAllowed      = array('a', 'abbr', 'acronym', 'address',
        'b', 'blockquote', 'br', 'caption', 'cite', 'code', 'dd', 'del', 'dfn',
        'div', 'dl', 'dt', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i',
        'img', 'ins', 'kbd', 'li', 'ol', 'p', 'pre', 'q', 'samp', 'span',
        'strong', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'tt',
        'ul', 'var',
  );

  /**
   * Enable or disable DEBUG mode
   * @var bool
   * @access public
   */
  public $debug                     = false;

  /**
   * XML Parser object
   * @var object
   * @access private
   */
  private $_parser                  = null;

  /**
   * Holds parsed data as array
   * @var array
   * @access private
   */
  private $_output                  = array();

  /**
   * Current length of cdata text (for cutting in mParse::parse())
   * @var integer
   * @access private
   */
  private $_curLength               = 0;

	/**
 	 * Max length of cdata
 	 * @var integer
 	 * @access private
 	 */
	private $_maxLength								= -1;

  /**
   * Holds all $_tagQueue lists tree from parsed domcument.
   *
   * Used for DEBUG purposes.
   *
   * @var array
   * @access private
   */
  private $_snapshots               = array();

  /**
   * Holds tags queue from parsed document.
   *
   * Used for DEBUG purposes.
   *
   * @var array
   * @access private
   */
  private $_tagQueue                = array();

  /**
   * Elements which have not closing tag
   * @var array
   * @access private
   */
  static private $_tagsClosed       = array('meta', 'img', 'link', 'br', 'col',
        'hr', 'base', 'area', 'input');

  /**
   * Attributes which can occur in any tags.
   *
   * @var array
   * @access private
   */
  static private $_attributesConst  = array('title', 'name', 'id', 'lang', 'class',
        'style');

  /**
   * All tags database
   *
   * Holds an array of all tags which can occur in input data as array key.
   * Each value of these array is an array of attributes which can occur
   * in corresponding to its tag.
   *
   * @var array
   * @access private
   */
  static private $_attributes      = array(
    'a'           => array('href', 'type', 'target'),
    'abbr'        => array(),
    'acronym'     => array(),
    'address'     => array(),
    'applet'      => array('codebase', 'code', 'object', 'width', 'height'),
    'area'        => array('shape', 'coords'),
    'b'           => array(),
    'base'        => array('href', 'target'),
    'bdo'         => array('dir'),
    'big'         => array(),
    'blockquote'  => array('cite'),
    'body'        => array(),
    'br'          => array(),
    'button'      => array('value', 'type', 'disabled'),
    'caption'     => array(),
    'cite'        => array(),
    'code'        => array(),
    'col'         => array('span', 'width'),
    'colgroup'    => array('span', 'width'),
    'dd'          => array(),
    'del'         => array('cite', 'datetime'),
    'dfn'         => array(),
    'div'         => array(),
    'dl'          => array(),
    'dt'          => array(),
    'em'          => array(),
    'fieldset'    => array(),
    'form'        => array('action', 'method', 'enctype', 'accept',
                    'accept-charset'),
    'frame'       => array('longdesc', 'src', 'frameborder', 'marginwidth',
                    'marginheight', 'noresize', 'scrolling'),
    'frameset'    => array('rows', 'cols'),
    'h1'          => array(),
    'h2'          => array(),
    'h3'          => array(),
    'h4'          => array(),
    'h5'          => array(),
    'h6'          => array(),
    'head'        => array('profile'),
    'hr'          => array(),
    'html'        => array(),
    'i'           => array(),
    'iframe'      => array('longdesc', 'src', 'frameborder', 'marginwidth',
                    'marginheight', 'scrolling', 'align', 'height', 'width'),
    'img'         => array('width', 'height', 'alt', 'usemap', 'src',
                    'longdesc', 'ismap'),
    'input'       => array('type', 'value', 'checked', 'disabled', 'readonly',
                    'size', 'maxlength', 'src', 'alt', 'usemap', 'ismap',
                    'accept'),
    'ins'         => array('cite', 'datetime'),
    'kbd'         => array(),
    'label'       => array('for'),
    'legend'      => array(),
    'li'          => array(),
    'link'        => array('charset', 'href', 'hreflang', 'type', 'media',
                    'target'),
    'map'         => array('name'),
    'meta'        => array('http-eqiv', 'name', 'content', 'scheme'),
    'noframes'    => array(),
    'noscript'    => array(),
    'object'      => array('declare', 'classid', 'codebase', 'data', 'type',
                    'codetype', 'archive', 'standby', 'height', 'width',
                    'usemap'),
    'ol'          => array(),
    'optgroup'    => array('size', 'multiple', 'disabled'),
    'option'      => array('size', 'multiple', 'disabled'),
    'p'           => array(),
    'param'       => array('value', 'valuetype', 'type'),
    'pre'         => array(),
    'q'           => array('cite'),
    's'           => array(),
    'samp'        => array(),
    'script'      => array('charset', 'type', 'src', 'defer'),
    'select'      => array('size', 'multiple', 'disabled'),
    'small'       => array(),
    'span'        => array(),
    'strike'      => array(),
    'strong'      => array(),
    'style'       => array('type', 'media', 'title'),
    'sub'         => array(),
    'sup'         => array(),
    'table'       => array('summary', 'width', 'border', 'frame', 'rules',
                    'cellspacing', 'cellpadding', 'dir'),
    'tbody'       => array(),
    'td'          => array('abbr', 'axis', 'headers', 'scope', 'rowspan',
                    'colspan', 'nowrap', 'width', 'height'),
    'textarea'    => array('name', 'rows', 'cols', 'disabled', 'readonly'),
    'tfoot'       => array(),
    'th'          => array('abbr', 'axis', 'headers', 'scope', 'rowspan',
                    'colspan', 'nowrap', 'width', 'height'),
    'thead'       => array(),
    'title'       => array(),
    'tr'          => array(),
    'tt'          => array(),
    'u'           => array(),
    'ul'          => array(),
    'var'         => array(),
  );

  /**
   * Attributes which value have to be checked by designed to it's method
   *
   * @var array
   * @access private
   */
  static private $_attributesExt  = array('href', 'src');

  /**
   * Contructor.
   *
   * Initializes XML Parser object and pass it to $this->_parser.
   *
   * @access public
   */
  public function __construct()
  {
    $this->_parser = xml_parser_create();

    xml_parser_set_option($this->_parser, XML_OPTION_CASE_FOLDING, false);
    xml_set_object($this->_parser, $this);
    xml_set_element_handler($this->_parser, '_tagOpen', '_tagClose');
    xml_set_character_data_handler($this->_parser, '_cdata');
    xml_set_default_handler($this->_parser, '_hDefault');
    xml_set_processing_instruction_handler($this->_parser, '_PIHandler');

    if (defined('DEBUG') && DEBUG) {
      $this->debug = true;
    }
  }

  /**
   * Destructor
   *
   * Deinitialize XML Parser object when destroy mParser object
   *
   * @access public
   */
  public function __destruct()
  {
    xml_parser_free($this->_parser);
  }

  /**
   * Converts data between different encodings
   *
   * @param string $data data to convert
   * @param string $from encoding of passed text
   * @param string $to encoding to return
   *
   * @access private
   */
  private function _convert($data, $from, $to)
  {
    if (extension_loaded('iconv')) {
      return iconv($from, $to, $data);
    } else if (extension_loaded('mbstring')) {
      return mb_convert_encoding($data, $to, $from);
    } else if (extension_loaded('recode')) {
      return recode_string(sprintf('%s..%s', $from, $to), $data);
    } else {
      throw new BadMethodCallException(sprintf('Encoding to "%s" failed: '.
        'cannot find extension to do it.', $enc_to));
    }
  }

  /**
   * Main engine of mParser class.
   *
   * If $wellFormed == false, all text is inserted into
   * mParser::VIRTTAG tags.
   * Usually is used text from textarea fields, where data aren't
   * well formed (in XML context), so its important to repair this.
   * If data is for example ready to use HTML file with it's content
   * enclosed into <html /> tag, call mParser::parse() with $wellFormed as true.
   *
   * @param string $data data to be parsed
   * @param integer $length max length of string to return
   * @param string $encoding encoding input data
   * @param bool $wellFormed defines that input text is well formed XML
   *
   * @return mixed
   * @access public
   */
  public function parse($data, $length=-1, $encoding='UTF-8', $wellFormed = false)
  {
		if ($length > -1) {
			$this->_maxLength = $length;
		}

    // if input data isn't well-formed XML, we must put it into virtual tags
    if (!$wellFormed) {
      $data = sprintf('<%s>%s</%s>', self::VIRTTAG, $data, self::VIRTTAG);
    }

    $data = $this->_convert($data, $encoding, 'UTF-8');

    xml_parse($this->_parser, $data);

    if ($this->debug) {
      echo implode("<br />\n", $this->_snapshots);
    }

    $data = implode('', $this->_output);
    return $this->_convert($data, 'UTF-8', $encoding);
  }

  /**
   * Handle start tag.
   *
   * Push into mParser::$_output start tag (or tag who hasn't closing tag).
   * If mParser::$debug is TRUE, it release mParser::_makeSnapshot() method.
   *
   * @param object $_parser
   * @param string $tag name of new tag
   * @param array $attributes list of tag attributes
   *
   * @return bool true
   *
   * @access private
   */
  private function _tagOpen($_parser, $tag, $attributes)
  {
		//doesn't process if we have enough text
		if ($this->_maxLength > -1 && $this->_curLength >= $this->_maxLength) {
			return true;
		}



    if (in_array($tag, self::$_tagsAllowed)) { //we want these tag in our output ?
      $htmlTag = '<' . $tag;

      if ($attributes) {

        foreach($attributes as $attr=>$val) {
          if (in_array($attr, self::$_attributes[$tag]) ||
            in_array($attr, self::$_attributesConst)) {

            if (in_array($attr, self::$_attributesExt)) {
							$val = call_user_func_array(array($this, '_checkattr_' . $attr), $val);
            }

            $htmlTag .= sprintf(' %s="%s"', $attr, $val);
          }

        }
      } //if ($attributes)

      $htmlTag .= in_array($tag, self::$_tagsClosed) ? ' />' : '>';

      $this->_output[]   = $htmlTag;
    } //if (in_array($tag, self::$_tagsAllowed))

    $this->_tagQueue[]  = $tag;

    if ($this->debug) {
      $this->_makeSnapshot();
    }
    if (in_array($tag, self::$_safeTree)) {
      ++$this->_nl2br;
    }

    return true;
  }

  /**
   * Handle character data in parsed text.
   *
   * Checks did text isn't in mParser::$_safe or mParser::_safeTree,
   * and if not, replace all new line chars with < br /> XHTML tag,
   * and behind it, content of mParser::$newline.
   * If mParser::$strip is set to FALSE, or character data
   * isn't empty, append parsed text into mParser::$_output.
   *
   * @param object $_parser
   * @param string $cdata founded character data
   *
   * @return bool true
   * @access private
   */
  private function _cdata($_parser, $cdata)
  {
		//doesn't process if we have enough text
		if ($this->_maxLength > -1 && $this->_curLength >= $this->_maxLength) {
			return true;
		}



    $last = end($this->_tagQueue);

    if ($this->debug) {
      $br = sprintf('<br type="%s" />', $last) . $this->newline;
    } else {
      $br = '<br />' . $this->newline;
    }

    if ($this->nl2br && 0 == $this->_nl2br && !in_array($last, self::$_safe)) {
      $cdata = str_replace(array("\r\n", "\r", "\n"), $br, $cdata);
    }

    if ($this->strip) {
      $cdata = trim($cdata, "\r\n");
    }

    if (!$this->strip || '' != trim($cdata)) {
			$l = strlen($cdata);
			if ($this->_maxLength > -1 && ($this->_curLength + $l) >= $this->_maxLength) {
				$cdata = substr($cdata, 0, $this->_maxLength - $this->_curLength);
			}
			$this->_curLength += $l;
      $this->_output[] = $cdata;
    }

    return true;
  }

  /**
   * Handle action at close tag.
   *
   * If handled tag isn't in mParser::$_safe or mParser::$_safeTree,
   * and is in mParser::$_tagsAllowed, append a close part of
   * tag into mParser::$_output.
   *
   * @param object $_parser
   * @param string $tag name of closed tag
   *
   * @return bool true
   *
   * @access private
   */
  private function _tagClose($_parser, $tag)
  {
		if ($tag != end($this->_tagQueue)) {
			return true;
		}

    if (!in_array($tag, self::$_tagsClosed) && in_array($tag, self::$_tagsAllowed)) {
      $this->_output[] = sprintf('</%s>', $tag);
    }

    array_pop($this->_tagQueue);

    if (in_array($tag, self::$_safeTree)) {
      --$this->_nl2br;
    }

    return true;
  }

  /**
   * Released when found other type of data, not handled by previuos methods.
   *
   * @param object $_parser
   * @param string $data
   *
   * @return bool true
   *
   * @access private
   */
  private function _hDefault($_parser, $cdata)
  {
		//doesn't process if we have enough text
		if ($this->_maxLength > -1 && $this->_curLength >= $this->_maxLength) {
			return true;
		}



    if (!$this->strip || trim($cdata) != '') {
      $this->_output[] = $cdata;
    }

    return true;
  }

  /**
   * If allowed, evaluate PHP code.
   *
   * @param object $_parser
   * @param string $target type of processing instruction
   * @param string $data
   *
   * @return string
   *
   * @access private
   */
  private function _PIHandler($_parser, $target, $data)
  {
    if ('php' == strtolower($target)) {
      if ($this->parsePHP) {
        return eval($data);
      } else {
        return Strings::entities($data);
      }
    }
  }

  /**
   * Creates snapshot of current tag queue. Used only for DEBUG purposes.
   *
   * @return bool true
   *
   * @access private
   */
  private function _makeSnapshot()
  {
    if (count($this->_tagQueue) > 1) {
      $this->_snapshots[] = implode(' -&gt; ', array_slice($this->_tagQueue, 1));
    }
    return true;
  }

  /**
   * Checker for value of 'href' attribute.
   *
   * Href cannot start with 'javascript' - XSS preventing.
   *
   * @param $val string value of href attribute
   *
   * @return string
   *
   * @access private
   */
  private function _checkattr_href($val)
  {
    static $pattern = array('#^[\s\0]*j[\s\0]*a[\s\0]*v[\s\0]*a[\s\0]*s[\s\0]*c[\s\0]*r[\s\0]*i[\s\0]*p[\s\0]*t[\s\0]*:#im', '#\0#im');
    return urldecode(preg_replace($pattern, '', urldecode($val)));
  }

  /**
   * Checker for value of 'src' attribute.
   *
   * Src cannot start with 'javascript' - XSS preventing.
   *
   * @param $val string value of src attribute
   *
   * @return string
   *
   * @access private
   */
  private function _checkattr_src($val)
  {
    return $this->_checkattr_href($val);
  }

}

// vim: ft=php ts=2 sw=2 noet
?>
